[FE302] React 基礎 - hooks 版本 (性能優化)


一、React 的渲染機制(Reconciliation)與 Virtual DOM

(兩大重點面試會考!)

陽春版

  1. 新增 todo 更改資料,由資料決定畫面
  2. 作法:state 改變 => 清空畫面 + render => DOM
  3. 缺點:效能隱憂,一百個 todo,就算只編輯其中一個,也還是要將一百個東西重新再 render 一次。

react 如何解決陽春版的缺點?換句話說,react 如何快速找出要改變的地方(Reconciliation)?

重點一、virtual DOM 進行比對

react component 在 render 並不是直接產生出真的 DOM,直接將改變應用到畫面上去,而是產生 virtual DOM (一個虛擬的 JS 物件)。所以他會有上個 state JS 的物件,以及下一個 state JS 的物件,就可以做這兩個 virtual DOM 之間的比對。

比對的過程就叫做 Reconciliation。最後再把這些改變的地方應用到 DOM 去。

DOM is a tree。我要怎麼找到樹裡面不同的節點?原本的時間複雜度為 O(n^3),但因為 react 多了一些假設因此時間複雜度也降低了。

假設一、若相對應節點不同,那我就假設下面的東西完全不能被共用。就不用再往目標節點下面的節點找。直接將整個節點拆掉換新的。因此少掉很多比對的時間。包括加的 list 需要 key 也是因為要幫助 react 找哪個元素有改變,就不用每個元素都動都比對。

效能也許沒有直接操作快,但至少也比完全把畫面清掉重新 render 一次快,算是一個折衷的方式。

重點二、透過中間層決定要將 JS 物件 render 出甚麼東西

因為 virtual DOM 不是真的 DOM,所以在網頁上面,REACT 這套 library 就可以將 virtual DOM 轉成真的 DOM。

{
  tag: 'div',
  props: {
    className: 'App'
  }
  children: {

  }
}
=> <div></div>

同樣的 code 如果不轉成 div,轉成 mark down 的語法 # div,就可以用 react 的語法 render 出 mark down。同理,若轉成手機 app 的語法,就可以把 react render 成手機的 component。

透過中間層他的 target 可以不同。

推薦文章:
Virtual DOM | 為了瞭解原理,那就來實作一個簡易 Virtual DOM 吧!
從頭打造一個簡單的 Virtual DOM
搜尋 virtual DOM 前幾篇都看一下

re-render

  1. 層面一:當 component state 改變,就會再 call 一次 function,這個 function 會 return 一個東西。那麼再 call 一次 function 的行為就是 re-render
  2. 層面二:當 state 改變,他找出 DOM diff 把他的東西 patch 到真的 DOM 上面,這個行為也叫 re-render

所以可能發生 state 改變,但這個 state 是 UI 上沒有用到的 state,因為 virtual DOM 沒有改變所以我真正的 DOM 並不會改變。這就代表我只有在層面一 re-render,所以她只有多做一次 virtual DOM 的 diff。並沒有把真的東西放到畫面上去。所以效能比真的放到畫面上去好,因為少了一個步驟。

但層面一的 re-render 其實沒有意義,因為我根本沒有用到。可是因為 state 改變,他在我 state 裡面所以我還是會重新 render 一次。但就實質意義而言,我不需要去做 render 的動作、virtual DOM 的比對,因為我知道他絕對不會有新的東西產生。

因為是找出真的要改的地方應用上去,所以 React 的效能會比陽春版的效能好很多。

二、如何避免 re-render?

先將 button 變成 component

  return (
    <div className="App">
      <input type="text" placeholder="todo" value={value} onChange={handleInputChange}/>
      <button onClick={handleButtonClick}>Add todo</button>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );

每次當 button render 他就會 call 一次這個 function,然後 console.log('render button'),所以我們可以檢是在甚麼情況下 button 會 render。

function Button({onClick, children}) {
  console.log('render button')
  return <button onClick={onClick}>{children}</button>
}
  return (
    <div className="App">
      <input type="text" placeholder="todo" value={value} onChange={handleInputChange}/>
      <Button onClick={handleButtonClick}>Add todo</Button>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );

每打一次字就會 render button,因為 value 的 state 改變,state 改變就會使 App 這個 component re-render。parent re-render children 會一起 re-render。

parent re-render 底下的所有 children 也會跟著一起 re-render!

我每打一個字,button 就會 re-render,但事實上 button 不需要 re-render。因為打字的 value 與 button 間沒有關係。

如何讓一個 component 不要 re-render ?

React 提供了 memo,可以將 component 又用 memo 包起來。

import {useState, useRef, useEffect, useLayoutEffect, memo} from 'react'

function Button({onClick, children}) {
  console.log('render button')
  return <button onClick={onClick}>{children}</button>
}
const MemoButton = memo(Button)

加上 memo 後,React 會自動檢測如果傳給 button 的 props,onClick 跟 children 都沒有變的話,他就不會 re-render。若有其中一個變了,他就會 re-render。

  return (
    <div className="App">
      <input type="text" placeholder="todo" value={value} onChange={handleInputChange}/>
      <MemoButton onClick={handleButtonClick}>Add todo</MemoButton>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );

重新打字後,發現還是 re-render,首先我們傳進去的 children 都沒有變,都是 Add todo。但 handleButtonClick 變了,我們現在是包在 hook 裡面,但每次跑 hook 都會產生新的 function,執行一個 hook 就把他想成執行一個 function。

執行第一次、第二次,裡面產生出來的會是不同的 instance。他有點像是重新 new 一次變數的感覺。

// 做的事情一樣,但兩個 function 不一樣,這就是變數指向的概念,他們會是兩個不同的 function
  const handleButtonClick = () => {
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])

    setValue('') // 將 todo 清空
    //id ++
    id.current ++
  }

    const handleButtonClick = () => {
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])
    setValue('') // 將 todo 清空
    //id ++
    id.current ++
  }

如果是這樣 <MemoButton>Add todo</MemoButton> 因為 props 沒變,所以就不會 re-render。

因為傳入的 handleButtonClick,每次都不一樣,所以每次都會 re-render,所以這個時候就會透過另外的 hook 去處理這件事情:useCallback

用法就是將 function 用 useCallBack 包住。一樣要傳第二個參數表示當我甚麼東西變了,我的 function 要改變,類似 useEffect 的用法

傳空陣列表示我沒有任何東西變的時候,我的 function 就不會改變。這個陣列全名是 dependency array,這個 function dependency 甚麼。

這樣寫表示 handleButtonClick 這個東西,我用 useCallback 這個 hook 包住,他就都不會改變。可以想成 useCallback 幫我們做了 memory 的事情。所以只有第一次 render 會執行到這邊,第二次就會用他已經存好的 function,所以就不會改變。

透過這的作法,打字就不會 re-render 但 function 還是正常執行。

import {useState, useRef, useEffect, useLayoutEffect, memo, useCallback} from 'react'

  const handleButtonClick = useCallback(() => {
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])
    setValue('') // 將 todo 清空
    //id ++
    id.current ++
  }, [])

小結

  1. memo 可以將 component 給包起來
  2. useCallback 可以將 function 給記憶,他就會永遠都是同一個 function

React Hook useCallback has missing dependencies

eslint 偵測到你在 react 當中有用到 setTodos 這個 function,所以當 setTodos 改變時,handleButtonClick 就該產生一個新的 function

  const handleButtonClick = useCallback(() => {
    console.log(value)
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])
    setValue('') // 將 todo 清空
    //id ++
    id.current ++
  }, [])

這樣寫新增的都是空的,因為我騙了 react:和 react 說只有第一次 render 這個 function 才會變,handleButtonClick 不會變。因為每次 render 都是重新執行 function,

這樣寫法的執行流程

  1. first render => value = ' '
  2. handleButtonClick 長相如下 => console.log(value) => ' '
  3. 打字 a => second render => value = 'a' => 再執行下面的 handleButtonClick,但因為 useCallback [],和 react 說好不管發生甚麼事情都不會產生新的 function,所以 handleButtonClick 就會是第一次的 function,又因為第一次的 function 在他的 scope 裡面他的 value = ' ',所以 console.log(value) => ' '
  4. render 的是兩個不同的 value
  const handleButtonClick = useCallback(() => {
    console.log(value)
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])
    setValue('') // 將 todo 清空
    //id ++
    id.current ++
  }, [])

依照 eslint 提示,要把用到的東西都加上去。
當這四個東西 [setValue, setTodos, value, todos] 有任一東西改變,就要重新產生 handleButtonClick 的 function,這樣裡面抓到的值才會是正確的。如果用到上個 render 的 function 就會吃到上個 render 的值。

  const handleButtonClick = useCallback(() => {
    console.log(value)
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])
    setValue('') // 將 todo 清空
    //id ++
    id.current ++
  }, [setValue, setTodos, value, todos])

請注意,要把每次 render 都想成一次 function call。每一個 function 都只看到自己這次 render 的值。

因為按下 Add Todo 會用到輸入的值,所以一樣要產生出新的 function 出來。所以最一開始講的是錯誤的假設,這個 button 按的時候不需要任何東西。他需要知道現在 input 裡面的值是多少,也需要知道現在 todo 的值是多少,才能做事情。

現在,新增另外一個 state 跟這個 <MemoButton onClick={handleButtonClick}>Add todo</MemoButton> 毫不相關,他就不會 re-render。因為他跟 handleButtonClick 無關。

此外,handleButtonClick 只有 [setValue, setTodos, value, todos] 改變才會重新產生 function。

  return (
    <div className="App">
      <input type="text" placeholder="todo" value={value} onChange={handleInputChange}/>
      <input type="text"/>
      <MemoButton onClick={handleButtonClick}>Add todo</MemoButton>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );

有時會需要傳一個物件,如下:

打字時會發現 test 也一直 re-render,為甚麼?原因是因為每次 render 都是一個新的物件,觀念參照物件 reference。舉例:{color: 'red'} === {color: 'red'} // false

function Test({style}) {
  console.log('test render')
  return <div style={style}>test</div>
}
function App() {
 略
   return (
    <div className="App">
      <Test style={{color: 'red'}} />
      <input type="text" placeholder="todo" value={value} onChange={handleInputChange}/>
      <MemoButton onClick={handleButtonClick}>Add todo</MemoButton>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );
}

解決方法:
法一:移到外面去,這樣一來每次 call App() 都是用同一個 const s = {color: 'red'}
缺點:s 可能會依據 value 的值不一樣,這時就不能寫在外面

function Test({style}) {
  console.log('test render')
  return <div style={style}>test</div>
}
const s = {color: 'red'}
function App() {
  略
  return (
    <div className="App">
      <Test style={s} />
      <input type="text" placeholder="todo" value={value} onChange={handleInputChange}/>
      <MemoButton onClick={handleButtonClick}>Add todo</MemoButton>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );
}

法二:放在 App() 裡面。

發現顏色不變時還是 re-render,這時可以藉由 useMemo 這個 hook 解決這個問題。import {useState, useRef, useEffect, useLayoutEffect, memo, useCallback, useMemo} from 'react'

留意:useMemo 是給資料用的,memo 是給 component 用的。通常會在計算量龐大時使用。舉例:今天做非常複雜的科學計算,如果 value 沒變我也要重新再執行一次,會非常耗資源。今天我使用 useMemo 我將複雜的計算包在這裡面。用法類似 useCallback,只不過是給 value 而非 function。

  const s = {color: value ? 'red' : 'blue'}
  return (
    <div className="App">
      <Test style={s} />
      <input type="text" placeholder="todo" value={value} onChange={handleInputChange}/>
      <MemoButton onClick={handleButtonClick}>Add todo</MemoButton>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );

useMemo 用法:傳給他一個 function,function 回傳 s 的值 const s = {color: value ? 'red' : 'blue'},後面一樣傳 dependency array。

這樣寫法表示:當 value 改變,我會重新執行一次這個東西,然後重新回傳一個新的物件。發現這樣寫和剛剛一樣,因為 value 改了就會重新執行一次。用來舉例不太適合。

  const s = useMemo(() => {
    return {
      color: value ? 'red' : 'blue',
    }
  }, [value])

只要 value 改變就重新計算一次 s 的值,其他東西改變就都不重新計算。也就是,只有 value 改變我才重新計算並回傳 s 應該要有的值。

const redStyle = {
  color: 'red'
}
const blueStyle = {
  color: 'blue'
}
function App() {
 略
   const s = useMemo(() => {
    console.log('calculate s')
    return {
      color: value ? redStyle : blueStyle,
    }
  }, [value])
}

小結

useMemo, useCallback 都是為了確保他的 reference 值是一樣的。useRef 也是類似的東西。這是幾個和 react 效能有關的 hook。

三、React 特別的事件機制

寫 code 時會在 input 上面放 onChange 或在 button 上面放 onClick。會想像他的 click function 是放在 DOM 上面。但其實不是 !

  return (
    <div className="App">
      <input type="text" placeholder="todo" value={value} onChange={handleInputChange}/>
      <Button onClick={handleButtonClick}>Add todo</Button>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );

dev-tool 右鍵檢查 button 確實有 click function 但將他 remove,button 的功能還是可以動。

真正的 eventListener 是放在 id = root 這層。React 是用事件代理,點擊任何東西他監聽的事件都是綁在 root 這層上面。會這麼做的原因 1. 效能好 2. 動態新增刪除東西,確保 eventListener 可以捕捉到正確資訊

所以點完成按鈕,她的事件最後是發到 root 這邊的 onClick 的 listener,再根據點擊的東西去做相對應的處理。

ReactDOM.render(
  <ThemeProvider theme={theme} >
    <App />
  </ThemeProvider>,
  document.getElementById('root')
);

結論:React 的事件機制是綁在上面的節點。(面試可能會問)
補充:event pooling
React 16 onClick 的 e 是共用的,React 17 將這個功能給停掉。

#virtual dom #useMemo #useCallback #memo #React #re-render #react 效能 #react 事件機制






你可能感興趣的文章

Elevate Your Dermatology Practice with the Electric Dermatology Chair

Elevate Your Dermatology Practice with the Electric Dermatology Chair

從製作 visfest 2019 badge 認識 ObservableHQ

從製作 visfest 2019 badge 認識 ObservableHQ

簡明約耳趣談軟體(Joel on Software)導讀書摘

簡明約耳趣談軟體(Joel on Software)導讀書摘






留言討論